use anyhow::Result;
use indexmap::IndexMap;
use log::warn;

use crate::model::*;

/// Resolve a RawPipeline (produced by the GitHub Actions parser) into a Pipeline.
///
/// This is simpler than the GitLab resolver because GitHub Actions workflows
/// don't have `extends`, `default`, or `$VAR`-style variable expansion.
/// The main tasks are:
/// - Assign jobs to their computed stage (from dependency depth)
/// - Merge global variables with job-level variables
/// - Build the resolved YAML text for display
pub fn resolve_pipeline(raw: RawPipeline) -> Result<Pipeline> {
    let mut jobs = IndexMap::new();
    let mut stage_jobs: IndexMap<String, Vec<String>> = IndexMap::new();

    // Initialize stage buckets
    for stage_name in &raw.stages {
        stage_jobs.insert(stage_name.clone(), Vec::new());
    }

    for (job_id, raw_job) in &raw.jobs {
        // Determine which stage this job belongs to based on its needs depth
        let stage_name = compute_job_stage(job_id, &raw.jobs, &raw.stages);

        // Merge global + job variables
        let mut variables: IndexMap<String, String> = IndexMap::new();
        let mut variable_origins: IndexMap<String, VarOrigin> = IndexMap::new();

        for (k, v) in &raw.variables {
            variables.insert(k.clone(), v.value().to_string());
            variable_origins.insert(k.clone(), VarOrigin::Global);
        }
        if let Some(ref job_vars) = raw_job.variables {
            for (k, v) in job_vars {
                let origin = if variables.contains_key(k) {
                    VarOrigin::GlobalOverriddenByJob
                } else {
                    VarOrigin::Job
                };
                variables.insert(k.clone(), v.value().to_string());
                variable_origins.insert(k.clone(), origin);
            }
        }

        // Add GitHub predefined environment variables
        add_github_predefined_vars(&mut variables, &mut variable_origins, &raw.github_context);

        // Image from container
        let image = raw_job.image.as_ref().map(|i| i.name().to_string());

        // Script lines
        let script: Vec<String> = raw_job
            .script
            .as_ref()
            .map(|s| s.as_slice().to_vec())
            .unwrap_or_default();

        // Needs — expand base job names to all their matrix-expanded variants
        let needs: Vec<ResolvedNeed> = raw_job
            .needs
            .as_ref()
            .map(|entries| {
                let mut resolved = Vec::new();
                for n in entries {
                    let base_name = n.job_name();
                    if raw.jobs.contains_key(base_name) {
                        // Direct match (no expansion)
                        resolved.push(ResolvedNeed {
                            job_name: base_name.to_string(),
                            artifacts: n.wants_artifacts(),
                        });
                    } else {
                        // Look for expanded variants: "base [key: val, ...]"
                        let prefix = format!("{} [", base_name);
                        let expanded: Vec<_> =
                            raw.jobs.keys().filter(|k| k.starts_with(&prefix)).collect();
                        if expanded.is_empty() {
                            // Keep the original reference even if unresolved
                            resolved.push(ResolvedNeed {
                                job_name: base_name.to_string(),
                                artifacts: n.wants_artifacts(),
                            });
                        } else {
                            for expanded_name in expanded {
                                resolved.push(ResolvedNeed {
                                    job_name: expanded_name.clone(),
                                    artifacts: n.wants_artifacts(),
                                });
                            }
                        }
                    }
                }
                resolved
            })
            .unwrap_or_default();

        // Tags from runs-on
        let tags: Vec<String> = raw_job.tags.clone().unwrap_or_default();

        // Rules summary from `if` condition
        let rules_summary = extract_rules_summary(raw_job);

        // Timeout
        let timeout = raw_job.timeout.clone();

        // Extract structured steps from extra data
        let steps = extract_steps(raw_job);

        // Build resolved YAML for display
        let resolved_yaml = build_resolved_yaml(job_id, raw_job, &raw.variables);

        // Debug info
        let source = raw.source_map.get(job_id).cloned();
        let debug_info = JobDebugInfo {
            source,
            extends_chain: vec![],
            key_provenance: IndexMap::new(),
            variable_origins,
        };

        let job = Job {
            name: job_id.clone(),
            stage: stage_name.clone(),
            image,
            services: vec![],
            variables,
            before_script: vec![],
            script,
            after_script: vec![],
            needs,
            tags,
            when_val: "on_success".to_string(),
            allow_failure: false,
            rules_never: false,
            unresolved_extends: vec![],
            rules_summary,
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_when: "on_success".to_string(),
            timeout,
            resource_group: None,
            coverage: None,
            resolved_yaml,
            trigger_file: None,
            steps,
            debug_info,
        };

        stage_jobs
            .entry(stage_name)
            .or_default()
            .push(job_id.clone());
        jobs.insert(job_id.clone(), job);
    }

    // Build stages list
    let stages: Vec<Stage> = raw
        .stages
        .iter()
        .filter_map(|name| {
            let job_names = stage_jobs.get(name)?;
            if job_names.is_empty() {
                return None;
            }
            Some(Stage {
                name: name.clone(),
                job_names: job_names.clone(),
            })
        })
        .collect();

    Ok(Pipeline { stages, jobs })
}

/// Determine which stage a job belongs to based on its dependency depth.
fn compute_job_stage(
    job_id: &str,
    jobs: &IndexMap<String, RawJobConfig>,
    stages: &[String],
) -> String {
    let depth = job_depth(job_id, jobs, &mut IndexMap::new(), &mut Vec::new());
    stages
        .get(depth)
        .cloned()
        .unwrap_or_else(|| depth.to_string())
}

/// Recursively compute the dependency depth of a job.
/// Handles matrix-expanded job names: if a need references "build" but only
/// "build [os: ubuntu]" exists, it resolves to the expanded variant.
fn job_depth(
    name: &str,
    jobs: &IndexMap<String, RawJobConfig>,
    cache: &mut IndexMap<String, usize>,
    stack: &mut Vec<String>,
) -> usize {
    if let Some(&d) = cache.get(name) {
        return d;
    }
    if stack.iter().any(|s| s == name) {
        warn!(
            "Dependency cycle detected: {} -> {}",
            stack.join(" -> "),
            name
        );
        return 0;
    }
    stack.push(name.to_string());

    let depth = jobs
        .get(name)
        .and_then(|job| job.needs.as_ref())
        .map(|needs| {
            needs
                .iter()
                .map(|n| {
                    let dep_name = n.job_name();
                    if jobs.contains_key(dep_name) {
                        job_depth(dep_name, jobs, cache, stack) + 1
                    } else {
                        // Find first expanded variant
                        let prefix = format!("{} [", dep_name);
                        jobs.keys()
                            .find(|k| k.starts_with(&prefix))
                            .map(|k| job_depth(k, jobs, cache, stack) + 1)
                            .unwrap_or(1)
                    }
                })
                .max()
                .unwrap_or(0)
        })
        .unwrap_or(0);

    stack.pop();
    cache.insert(name.to_string(), depth);
    depth
}

/// Extract structured GitHub Actions steps from a raw job's extra data.
fn extract_steps(raw_job: &RawJobConfig) -> Vec<Step> {
    let Some(steps_value) = raw_job.extra.get("_github_steps") else {
        return vec![];
    };
    let serde_yaml::Value::Sequence(entries) = steps_value else {
        return vec![];
    };
    entries
        .iter()
        .filter_map(|entry| {
            let map = entry.as_mapping()?;

            let get_str = |key: &str| -> Option<String> {
                map.get(serde_yaml::Value::String(key.to_string()))?
                    .as_str()
                    .map(String::from)
            };

            let with = map
                .get(serde_yaml::Value::String("with".to_string()))
                .and_then(|v| v.as_mapping())
                .map(|m| {
                    m.iter()
                        .filter_map(|(k, v)| {
                            Some((k.as_str()?.to_string(), v.as_str()?.to_string()))
                        })
                        .collect()
                })
                .unwrap_or_default();

            let env = map
                .get(serde_yaml::Value::String("env".to_string()))
                .and_then(|v| v.as_mapping())
                .map(|m| {
                    m.iter()
                        .filter_map(|(k, v)| {
                            Some((k.as_str()?.to_string(), v.as_str()?.to_string()))
                        })
                        .collect()
                })
                .unwrap_or_default();

            Some(Step {
                name: get_str("name"),
                uses: get_str("uses"),
                run: get_str("run"),
                with,
                env,
                if_condition: get_str("if"),
                id: get_str("id"),
            })
        })
        .collect()
}

/// Extract rules summary lines from a raw job's rules field.
fn extract_rules_summary(raw_job: &RawJobConfig) -> Vec<String> {
    let Some(ref rules) = raw_job.rules else {
        return vec![];
    };
    let serde_yaml::Value::Sequence(entries) = rules else {
        return vec![];
    };
    entries
        .iter()
        .filter_map(|entry| {
            let map = entry.as_mapping()?;
            let cond = map
                .get(serde_yaml::Value::String("if".to_string()))?
                .as_str()?;
            Some(format!("if: {}", cond))
        })
        .collect()
}

/// Build a YAML representation of the resolved job for the Config tab.
fn build_resolved_yaml(
    job_id: &str,
    raw_job: &RawJobConfig,
    global_vars: &IndexMap<String, VarValue>,
) -> String {
    let mut lines = Vec::new();
    lines.push(format!("{}:", job_id));

    // runs-on (stored as tags)
    if let Some(ref tags) = raw_job.tags {
        if tags.len() == 1 {
            lines.push(format!("  runs-on: {}", tags[0]));
        } else {
            lines.push("  runs-on:".to_string());
            for tag in tags {
                lines.push(format!("    - {}", tag));
            }
        }
    }

    // container
    if let Some(ref image) = raw_job.image {
        lines.push(format!("  container: {}", image.name()));
    }

    // env (global + job)
    let has_global = !global_vars.is_empty();
    let has_job = raw_job.variables.as_ref().is_some_and(|v| !v.is_empty());
    if has_global || has_job {
        lines.push("  env:".to_string());
        for (k, v) in global_vars {
            lines.push(format!("    {}: {}", k, v.value()));
        }
        if let Some(ref vars) = raw_job.variables {
            for (k, v) in vars {
                lines.push(format!("    {}: {}", k, v.value()));
            }
        }
    }

    // needs
    if let Some(ref needs) = raw_job.needs {
        if needs.len() == 1 {
            lines.push(format!("  needs: {}", needs[0].job_name()));
        } else {
            lines.push("  needs:".to_string());
            for need in needs {
                lines.push(format!("    - {}", need.job_name()));
            }
        }
    }

    // steps (from script lines)
    if let Some(ref script) = raw_job.script {
        lines.push("  steps:".to_string());
        // Group script lines back into logical steps (split on blank lines)
        for line in script.as_slice() {
            lines.push(format!("    {}", line));
        }
    }

    lines.join("\n")
}

/// Add GitHub Actions predefined environment variables.
///
/// These mirror the `GITHUB_*` and `RUNNER_*` variables that GitHub Actions
/// runners inject automatically.  Values are derived from the git context
/// detected at parse time.  User-defined variables are never overwritten.
fn add_github_predefined_vars(
    vars: &mut IndexMap<String, String>,
    origins: &mut IndexMap<String, VarOrigin>,
    github_ctx: &std::collections::HashMap<String, String>,
) {
    let get = |key: &str| github_ctx.get(key).cloned().unwrap_or_default();

    let predefined = [
        ("CI", "true".to_string()),
        ("GITHUB_ACTIONS", "true".to_string()),
        ("GITHUB_SHA", get("sha")),
        ("GITHUB_REF", get("ref")),
        ("GITHUB_REF_NAME", get("ref_name")),
        ("GITHUB_HEAD_REF", get("head_ref")),
        ("GITHUB_ACTOR", get("actor")),
        ("GITHUB_REPOSITORY", get("repository")),
        ("GITHUB_REPOSITORY_OWNER", get("repository_owner")),
        ("GITHUB_SERVER_URL", get("server_url")),
        ("GITHUB_API_URL", format!("{}/api/v3", get("server_url"))),
        ("GITHUB_EVENT_NAME", get("event_name")),
        ("GITHUB_WORKSPACE", get("workspace")),
        ("GITHUB_RUN_ID", get("run_id")),
        ("GITHUB_RUN_NUMBER", get("run_number")),
        ("GITHUB_OUTPUT", "/tmp/github_output".to_string()),
        ("GITHUB_ENV", "/tmp/github_env".to_string()),
        ("GITHUB_PATH", "/tmp/github_path".to_string()),
        (
            "GITHUB_STEP_SUMMARY",
            "/tmp/github_step_summary".to_string(),
        ),
        ("RUNNER_OS", "Linux".to_string()),
        ("RUNNER_ARCH", std::env::consts::ARCH.to_string()),
        ("RUNNER_TEMP", "/tmp".to_string()),
        ("RUNNER_TOOL_CACHE", "/opt/hostedtoolcache".to_string()),
    ];

    for (key, value) in predefined {
        if !vars.contains_key(key) {
            vars.insert(key.to_string(), value);
            origins.insert(key.to_string(), VarOrigin::Predefined);
        }
    }
}

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

    fn make_raw_pipeline(yaml: &str) -> RawPipeline {
        crate::parser::github::parse_workflow_file_from_str(yaml).unwrap()
    }

    #[test]
    fn test_resolve_basic_workflow() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: cargo build
"#,
        ))
        .unwrap();

        assert_eq!(pipeline.stages.len(), 1);
        assert_eq!(pipeline.jobs.len(), 1);

        let build = &pipeline.jobs["build"];
        assert_eq!(build.stage, "0");
        assert_eq!(build.tags, vec!["ubuntu-latest"]);
        assert!(!build.script.is_empty());
    }

    #[test]
    fn test_resolve_with_needs() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps: [{run: make}]
  test:
    runs-on: ubuntu-latest
    needs: build
    steps: [{run: make test}]
"#,
        ))
        .unwrap();

        assert_eq!(pipeline.stages.len(), 2);
        let build = &pipeline.jobs["build"];
        let test = &pipeline.jobs["test"];
        assert_eq!(build.stage, "0");
        assert_eq!(test.stage, "1");
        assert_eq!(test.need_names(), vec!["build"]);
    }

    #[test]
    fn test_resolve_global_variables() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
env:
  RUST_BACKTRACE: "1"
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      CARGO_TERM_COLOR: always
    steps: [{run: cargo test}]
"#,
        ))
        .unwrap();

        let test = &pipeline.jobs["test"];
        assert_eq!(test.variables["RUST_BACKTRACE"], "1");
        assert_eq!(test.variables["CARGO_TERM_COLOR"], "always");
    }

    #[test]
    fn test_resolve_steps() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: cargo build
        env:
          RUSTFLAGS: "-D warnings"
      - name: Test
        run: cargo test
        if: success()
"#,
        ))
        .unwrap();

        let build = &pipeline.jobs["build"];
        assert_eq!(build.steps.len(), 3);

        // First step: uses action
        assert_eq!(build.steps[0].uses.as_deref(), Some("actions/checkout@v4"));
        assert!(build.steps[0].run.is_none());

        // Second step: run with env
        assert_eq!(build.steps[1].name.as_deref(), Some("Build"));
        assert_eq!(build.steps[1].run.as_deref(), Some("cargo build"));
        assert_eq!(build.steps[1].env["RUSTFLAGS"], "-D warnings");

        // Third step: run with condition
        assert_eq!(build.steps[2].name.as_deref(), Some("Test"));
        assert_eq!(build.steps[2].if_condition.as_deref(), Some("success()"));
    }

    #[test]
    fn test_resolve_container_image() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
jobs:
  test:
    runs-on: ubuntu-latest
    container: rust:1.80
    steps: [{run: cargo test}]
"#,
        ))
        .unwrap();

        assert_eq!(pipeline.jobs["test"].image.as_deref(), Some("rust:1.80"));
    }

    #[test]
    fn test_predefined_variables_injected() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps: [{run: echo hi}]
"#,
        ))
        .unwrap();

        let build = &pipeline.jobs["build"];
        assert_eq!(build.variables["CI"], "true");
        assert_eq!(build.variables["GITHUB_ACTIONS"], "true");
        assert_eq!(build.variables["RUNNER_OS"], "Linux");
        // These should exist (even if empty from missing context)
        assert!(build.variables.contains_key("GITHUB_SHA"));
        assert!(build.variables.contains_key("GITHUB_REF"));
        assert!(build.variables.contains_key("GITHUB_ACTOR"));
        assert!(build.variables.contains_key("GITHUB_OUTPUT"));
        assert!(build.variables.contains_key("GITHUB_ENV"));
        assert!(build.variables.contains_key("GITHUB_STEP_SUMMARY"));
    }

    #[test]
    fn test_predefined_variables_do_not_overwrite_user_vars() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
env:
  CI: "custom"
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      RUNNER_OS: "Windows"
    steps: [{run: echo hi}]
"#,
        ))
        .unwrap();

        let build = &pipeline.jobs["build"];
        // User-defined values should win over predefined
        assert_eq!(build.variables["CI"], "custom");
        assert_eq!(build.variables["RUNNER_OS"], "Windows");
    }

    #[test]
    fn test_predefined_variables_origin() {
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps: [{run: echo hi}]
"#,
        ))
        .unwrap();

        let build = &pipeline.jobs["build"];
        assert_eq!(
            build.debug_info.variable_origins["GITHUB_ACTIONS"],
            VarOrigin::Predefined
        );
        assert_eq!(
            build.debug_info.variable_origins["RUNNER_OS"],
            VarOrigin::Predefined
        );
    }

    #[test]
    fn test_job_depth_no_needs() {
        let mut jobs = IndexMap::new();
        jobs.insert("build".to_string(), RawJobConfig::default());
        let mut cache = IndexMap::new();
        let mut stack = vec![];
        assert_eq!(job_depth("build", &jobs, &mut cache, &mut stack), 0);
    }

    #[test]
    fn test_job_depth_linear_chain() {
        let mut jobs = IndexMap::new();
        jobs.insert("build".to_string(), RawJobConfig::default());
        let mut test_job = RawJobConfig::default();
        test_job.needs = Some(vec![NeedEntry::Simple("build".to_string())]);
        jobs.insert("test".to_string(), test_job);
        let mut deploy_job = RawJobConfig::default();
        deploy_job.needs = Some(vec![NeedEntry::Simple("test".to_string())]);
        jobs.insert("deploy".to_string(), deploy_job);

        let mut cache = IndexMap::new();
        let mut stack = vec![];
        assert_eq!(job_depth("deploy", &jobs, &mut cache, &mut stack), 2);
        assert_eq!(cache["build"], 0);
        assert_eq!(cache["test"], 1);
        assert_eq!(cache["deploy"], 2);
    }

    #[test]
    fn test_job_depth_cycle_returns_zero_and_warns() {
        // a -> b -> a (cycle)
        let mut jobs = IndexMap::new();
        let mut a = RawJobConfig::default();
        a.needs = Some(vec![NeedEntry::Simple("b".to_string())]);
        jobs.insert("a".to_string(), a);
        let mut b = RawJobConfig::default();
        b.needs = Some(vec![NeedEntry::Simple("a".to_string())]);
        jobs.insert("b".to_string(), b);

        let mut cache = IndexMap::new();
        let mut stack = vec![];
        // Should not panic; cycle node returns 0
        let depth = job_depth("a", &jobs, &mut cache, &mut stack);
        // The depth is deterministic: a depends on b which cycles back to a (depth 0),
        // so b gets depth 1, and a gets depth 2.
        // But the key property is: it terminates without panic.
        assert!(depth <= 2);
    }

    #[test]
    fn test_resolve_cycle_does_not_panic() {
        // GitHub Actions workflow with a dependency cycle — should resolve
        // without panic (cycle is caught by job_depth)
        let pipeline = resolve_pipeline(make_raw_pipeline(
            r#"
on: push
jobs:
  a:
    runs-on: ubuntu-latest
    needs: b
    steps: [{run: echo a}]
  b:
    runs-on: ubuntu-latest
    needs: a
    steps: [{run: echo b}]
"#,
        ))
        .unwrap();

        // Both jobs exist; exact stage assignment doesn't matter,
        // the important thing is no panic or infinite loop
        assert_eq!(pipeline.jobs.len(), 2);
    }
}
